---
id: saas
title: 11. SaaS 多租户
sidebar_label: 11. SaaS 多租户
---

import useBaseUrl from "@docusaurus/useBaseUrl";

## 11.1 什么是 `SaaS`

> SaaS 是 Software-as-a-Service（软件即服务）的简称，随着互联网技术的发展和应用软件的成熟， 在 21 世纪开始兴起的一种完全创新的软件应用模式。它与“on-demand software”，the application service provider(ASP，应用服务提供商)，hosted software(托管软件)所具有相似的含义。
>
> 它是一种通过 Internet 提供软件的模式，厂商将应用软件统一部署在自己的服务器上，客户可以根据自己实际需求，通过互联网向厂商定购所需的应用软件服务，按定购的服务多少和时间长短向厂商支付费用，并通过互联网获得厂商提供的服务。用户不用再购买软件，而改用向提供商租用基于 Web 的软件，来管理企业经营活动，且无需对软件进行维护，服务提供商会全权管理和维护软件，软件厂商在向客户提供互联网应用的同时，也提供软件的离线操作和本地数据存储，让用户随时随地都可以使用其定购的软件和服务。
>
> 对于许多小型企业来说，SaaS 是采用先进技术的最好途径，它消除了企业购买、构建和维护基础设施和应用程序的需要。

## 11.2 什么是多租户

多租户技术或称多重租赁技术，简称 `SaaS`，是一种软件架构技术，是实现如何在多用户环境下（此处的多用户一般是面向企业用户）共用相同的系统或程序组件，并且可确保各用户间数据的隔离性。

**简单讲：在一台服务器上运行单个应用实例，它为多个租户（客户）提供服务。**从定义中我们可以理解：多租户是一种架构，目的是为了让多用户环境下使用同一套程序，且保证用户间数据隔离。那么重点就很浅显易懂了，多租户的重点就是同一套程序下实现多用户数据的隔离。

## 11.3 实现多租户方案

### 11.3.1 独立数据库(基于 `Database` 的方式)

这是第一种方案，即一个租户一个数据库，这种方案的用户数据隔离级别最高，安全性最好，但成本较高。

- **优点：**
  为不同的租户提供独立的数据库，有助于简化数据模型的扩展设计，满足不同租户的独特需求；如果出现故障，恢复数据比较简单。

- **缺点：**
  增多了数据库的安装数量，随之带来维护成本和购置成本的增加。 这种方案与传统的一个客户、一套数据、一套部署类似，差别只在于软件统一部署在运营商那里。如果面对的是银行、医院等需要非常高数据隔离级别的租户，可以选择这种模式，提高租用的定价。如果定价较低，产品走低价路线，这种方案一般对运营商来说是无法承受的。

### 11.3.2 共享数据库，独立 `Schema` (基于 `Schema` 的方式)

这是第二种方案，即多个或所有租户共享 `Database`，但是每个租户一个 `Schema`（也可叫做一个 user）。底层库比如是：`SqlServer`、`Oracle` 等，一个数据库下可以有多个 `Schema`。

- **优点：**
  为安全性要求较高的租户提供了一定程度的逻辑数据隔离，并不是完全隔离；每个数据库可支持更多的租户数量。

- **缺点：**
  如果出现故障，数据恢复比较困难，因为恢复数据库将牵涉到其他租户的数据； 如果需要跨租户统计数据，存在一定困难。

### 11.3.3 共享数据库，共享 `Schema` (基于 `TenantId` 的方式)

共享数据表 这是第三种方案，即租户共享同一个 `Database`、同一个 `Schema`，但在表中增加 **`TenantId`** 多租户的数据字段。这是共享程度最高、隔离级别最低的模式。 即每插入一条数据时都需要有一个客户的标识。这样才能在同一张表中区分出不同客户的数据。

- **优点：**
  三种方案比较，第三种方案的维护和购置成本最低，允许每个数据库支持的租户数量最多。

- **缺点：**
  隔离级别最低，安全性最低，需要在设计开发时加大对安全的开发量； 数据备份和恢复最困难，需要逐表逐条备份和还原。

## 11.4 多租户使用方案

`Furion` 框架支持以上三种多租户实现方案，使用简单且容易维护。下面分别使用三种不同方式演示多租户方案用法。

:::important 特别说明

一旦 `数据库上下文` 类继承了租户任意接口，则自动开始多租户功能支持。

:::

## 11.5 基于 `TenantId` 的方式

此方式在中小型企业系统中最为常用，维护成本低，购置成本低。

### 11.5.1 创建租户数据库上下文

```cs {6-7} title="Furion.EntityFramework.Core\DbContexts\MultiTenantDbContext.cs"
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
    [AppDbContext("Sqlite3ConnectionString")]
    public class MultiTenantDbContext : AppDbContext<MultiTenantDbContext, MultiTenantDbContextLocator>
    {
        public MultiTenantDbContext(DbContextOptions<MultiTenantDbContext> options) : base(options)
        {
        }
    }
}
```

:::important 特别注意

多租户操作建议单独一个数据库上下文，而且需指定 `MultiTenantDbContextLocator` 数据库上下文定位器。

:::

### 11.5.2 注册多租户数据库上下文

```cs {14}
using Furion.DatabaseAccessor;
using Microsoft.Extensions.DependencyInjection;

namespace Furion.EntityFramework.Core
{
    [AppStartup(600)]
    public sealed class FurEntityFrameworkCoreStartup : AppStartup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDatabaseAccessor(options =>
            {
                options.AddDbPool<FurionDbContext>(DbProvider.Sqlite);
                options.AddDbPool<MultiTenantDbContext, MultiTenantDbContextLocator>(DbProvider.Sqlite);
            });
        }
    }
}
```

### 11.5.3 添加 `Tenant` 种子数据

```cs {8,12-28} title="Furion.EntityFramework.Core\SeedDatas\TenantSeedData.cs"
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;

namespace Furion.EntityFramework.Core
{
    public class TenantSeedData : IEntitySeedData<Tenant, MultiTenantDbContextLocator>
    {
        public IEnumerable<Tenant> HasData(DbContext dbContext, Type dbContextLocator)
        {
            return new List<Tenant>
            {
                new Tenant
                {
                    TenantId = Guid.Parse("383AFB88-F519-FFF8-B364-6D563BF3687F"),
                    Name = "默认租户",
                    Host = "localhost:44313",
                    CreatedTime = DateTime.Parse("2020-10-06 20:19:07")
                },
                new Tenant
                {
                    TenantId = Guid.Parse("C5798CB6-16D6-0F42-EB56-59C695353BC0"),
                    Name = "其他租户",
                    Host = "localhost:5000",
                    CreatedTime = DateTime.Parse("2020-10-06 20:20:32")
                }
            };
        }
    }
}
```

:::note 特别说明

该步骤只在 `Code First` 方式执行，`Database First` 无需配置种子数据。

:::

### 11.5.4 根据模型创建 `Tenant` 表

```shell
Add-Migration add_tenant_table -Context MultiTenantDbContext
```

```shell
Update-Database -Context MultiTenantDbContext
```

### 11.5.5 实现 `IMultiTenantOnTable` 接口

在需要多租户的数据库上下文中实现 `IMultiTenantOnTable` 接口，如：

```cs {8,14-17}
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using System;

namespace Furion.EntityFramework.Core
{
    [AppDbContext("Sqlite3ConnectionString")]
    public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnTable, IModelBuilderFilter
    {
        public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
        {
        }

        public object GetTenantId()
        {
           return base.Tenant?.TenantId ?? Guid.Empty;
        }
    }
}
```

在 `GetTenantId()` 方法中，首先获取请求的 `主机地址`，然后根据主机地址查询对应的租户 `TenantId`，避免多次查询数据库，这里使用了 `IMemoryCache` 内存缓存。

:::important 特别说明

`base.Tenant` 只是 `Furion` 框架提供的默认租户实现方法，如果不能满足业务需求，只需要在 `GetTenantId` 里面写你的业务代码即可，也就是无需调用 `base.Tenant`。如：

```cs
public object GetTenantId()
{
   // 这里是你获取 TenantId 的逻辑
   return 你的 TenantId;
}
```

:::

### 11.5.6 实现 `IModelBuilderFilter` 接口

`IModelBuilderFilter` 接口是全局查询过滤器实现接口，所以我们需要配置实体 `TenantId` 过滤器

```cs {9,20-23}
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System;

namespace Furion.EntityFramework.Core
{
    [AppDbContext("Sqlite3ConnectionString")]
    public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnTable, IModelBuilderFilter
    {
        public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
        {
        }

        public object GetTenantId()
        {
            return base.Tenant?.TenantId ?? Guid.Empty;
        }

        public void OnCreating(ModelBuilder modelBuilder, EntityTypeBuilder entityBuilder, DbContext dbContext, Type dbContextLocator)
        {
            entityBuilder.HasQueryFilter(TenantIdQueryFilterExpression(entityBuilder, dbContext));
        }
    }
}
```

### 11.5.7 重写 `SavingChangesEvent` 事件方法

通过上面的步骤，我们已经解决了 `查询` 租户过滤功能，但是 `新增` 和 `更新` 还未处理。

- `新增` 数据的时候自动设置 `TenantId` 的值
- `更新` 数据的时候排除 `TenantId` 属性更新

实现上面的步骤很简单，只需要重写 `SavingChangesEvent` 事件方法即可。

```cs {26-48}
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System;
using System.Linq;

namespace Furion.EntityFramework.Core
{
    [AppDbContext("Sqlite3ConnectionString")]
    public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnTable, IModelBuilderFilter
    {
        public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
        {
        }

        public object GetTenantId()
        {
            return base.Tenant?.TenantId ?? Guid.Empty;
        }

        public void OnCreating(ModelBuilder modelBuilder, EntityTypeBuilder entityBuilder, DbContext dbContext, Type dbContextLocator)
        {
            entityBuilder.HasQueryFilter(TenantIdQueryFilterExpression(entityBuilder, dbContext));
        }

        protected override void SavingChangesEvent(DbContextEventData eventData, InterceptionResult<int> result)
        {
            // 获取当前事件对应上下文
            var dbContext = eventData.Context;

            // 获取所有新增和更新的实体
            var entities = dbContext.ChangeTracker.Entries().Where(u => u.State == EntityState.Added || u.State == EntityState.Modified);

            foreach (var entity in entities)
            {
                switch (entity.State)
                {
                    // 自动设置租户Id
                    case EntityState.Added:
                        entity.Property(nameof(Entity.TenantId)).CurrentValue = GetTenantId();
                        break;
                    // 排除租户Id
                    case EntityState.Modified:
                        entity.Property(nameof(Entity.TenantId)).IsModified = false;
                        break;
                }
            }
        }
    }
}
```

<img src={useBaseUrl("img/saas1.png")} />

## 11.6 基于 `Database` 的方式

此方式在中大型企业系统中最为常用，一个租户（客户）一个独立的数据库。

### 11.6.1 创建租户数据库上下文

```cs {6-7} title="Furion.EntityFramework.Core\DbContexts\MultiTenantDbContext.cs"
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
    [AppDbContext("Sqlite3ConnectionString")]
    public class MultiTenantDbContext : AppDbContext<MultiTenantDbContext, MultiTenantDbContextLocator>
    {
        public MultiTenantDbContext(DbContextOptions<MultiTenantDbContext> options) : base(options)
        {
        }
    }
}
```

:::important 特别注意

多租户操作建议单独一个数据库上下文，而且需指定 `MultiTenantDbContextLocator` 数据库上下文定位器。

:::

### 11.6.2 注册多租户数据库上下文

```cs {13,14}
using Furion.DatabaseAccessor;
using Microsoft.Extensions.DependencyInjection;

namespace Furion.EntityFramework.Core
{
    [AppStartup(600)]
    public sealed class FurEntityFrameworkCoreStartup : AppStartup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDatabaseAccessor(options =>
            {
                options.AddDb<FurionDbContext>(DbProvider.Sqlite);
                options.AddDbPool<MultiTenantDbContext, MultiTenantDbContextLocator>(DbProvider.Sqlite);
            });
        }
    }
}
```

:::caution 特别注意

需要 `Database` 多租户方案的数据库上下文需要采用 `AddDb` 注册，而不是 `AddDbPool`。原因是 `AddDbPool` 方式注册后续不支持 `OnConfiguring` 重写！！！

:::

### 11.6.3 添加 `Tenant` 种子数据

```cs {8,12-30} title="Furion.EntityFramework.Core\SeedDatas\TenantSeedData.cs"
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;

namespace Furion.EntityFramework.Core
{
    public class TenantSeedData : IEntitySeedData<Tenant, MultiTenantDbContextLocator>
    {
        public IEnumerable<Tenant> HasData(DbContext dbContext, Type dbContextLocator)
        {
            return new List<Tenant>
            {
                new Tenant
                {
                    TenantId = Guid.Parse("383AFB88-F519-FFF8-B364-6D563BF3687F"),
                    Name = "默认租户",
                    Host = "localhost:44313",
                    CreatedTime = DateTime.Parse("2020-10-06 20:19:07"),
                    ConnectionString = "Data Source=./Furion.db" // 配置连接字符串
                },
                new Tenant
                {
                    TenantId = Guid.Parse("C5798CB6-16D6-0F42-EB56-59C695353BC0"),
                    Name = "其他租户",
                    Host = "localhost:5000",
                    CreatedTime = DateTime.Parse("2020-10-06 20:20:32"),
                    ConnectionString = "Data Source=./Fur2.db" // 配置连接字符串
                }
            };
        }
    }
}
```

:::note 特别说明

该步骤只在 `Code First` 方式执行，`Database First` 无需配置种子数据。

:::

### 11.6.4 根据模型创建 `Tenant` 表

```shell
Add-Migration add_tenant_table -Context MultiTenantDbContext
```

```shell
Update-Database -Context MultiTenantDbContext
```

### 11.6.5 实现 `IMultiTenantOnDatabase` 接口

在需要多租户的数据库上下文中实现 `IMultiTenantOnDatabase` 接口，如：

```cs {6,12-15}
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
    public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnDatabase
    {
        public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
        {
        }

        public string GetDatabaseConnectionString()
        {
            return base.Tenant?.ConnectionString??"默认链接字符串";
        }
    }
}
```

:::important 特别说明

`base.Tenant` 只是 `Furion` 框架提供的默认租户实现方法，如果不能满足业务需求，只需要在 `GetDatabaseConnectionString` 里面写你的业务代码即可，也就是无需调用 `base.Tenant`。如：

```cs
public string GetDatabaseConnectionString()
{
   // 这里是你获取 DatabaseConnecionString 的逻辑
   return 你的 连接字符串;
}
```

:::

### 11.6.6 重写 `OnConfiguring` 方法

在需要多租户的数据库上下文中重写 `OnConfiguring` 方法并配置连接字符串：

```cs {12-17}
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
    public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnDatabase
    {
        public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
        {
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlite(GetDatabaseConnectionString());

            base.OnConfiguring(optionsBuilder);
        }

        public string GetDatabaseConnectionString()
        {
            return base.Tenant?.ConnectionString??"默认链接字符串";
        }
    }
}
```

<img src={useBaseUrl("img/saas2.png")} />

:::caution 特别注意

基于 `Database` 方式做 `Code First` 的时候，需要手动指定迁移程序名称，如：

```cs
optionsBuilder.UseSqlite(GetDatabaseConnectionString(), options=>
{
    options.MigrationsAssembly("My.Migrations");
});
```

:::

## 11.7 基于 `Schema` 的方式

此方式在中小型企业系统中也不少见，一个租户（客户）共享数据库且不同 `Schema`。

### 11.7.1 创建租户数据库上下文

```cs {6-7} title="Furion.EntityFramework.Core\DbContexts\MultiTenantDbContext.cs"
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
    [AppDbContext("Sqlite3ConnectionString")]
    public class MultiTenantDbContext : AppDbContext<MultiTenantDbContext, MultiTenantDbContextLocator>
    {
        public MultiTenantDbContext(DbContextOptions<MultiTenantDbContext> options) : base(options)
        {
        }
    }
}
```

:::important 特别注意

多租户操作建议单独一个数据库上下文，而且需指定 `MultiTenantDbContextLocator` 数据库上下文定位器。

:::

### 11.7.2 注册多租户数据库上下文

```cs {14}
using Furion.DatabaseAccessor;
using Microsoft.Extensions.DependencyInjection;

namespace Furion.EntityFramework.Core
{
    [AppStartup(600)]
    public sealed class FurEntityFrameworkCoreStartup : AppStartup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDatabaseAccessor(options =>
            {
                options.AddDbPool<FurionDbContext>(DbProvider.Sqlite);
                options.AddDbPool<MultiTenantDbContext, MultiTenantDbContextLocator>(DbProvider.Sqlite);
            });
        }
    }
}
```

### 11.7.3 添加 `Tenant` 种子数据

```cs {8,12-30} title="Furion.EntityFramework.Core\SeedDatas\TenantSeedData.cs"
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;

namespace Furion.EntityFramework.Core
{
    public class TenantSeedData : IEntitySeedData<Tenant, MultiTenantDbContextLocator>
    {
        public IEnumerable<Tenant> HasData(DbContext dbContext, Type dbContextLocator)
        {
            return new List<Tenant>
            {
                new Tenant
                {
                    TenantId = Guid.Parse("383AFB88-F519-FFF8-B364-6D563BF3687F"),
                    Name = "默认租户",
                    Host = "localhost:44313",
                    CreatedTime = DateTime.Parse("2020-10-06 20:19:07"),
                    Schema = "dbo" // Schema
                },
                new Tenant
                {
                    TenantId = Guid.Parse("C5798CB6-16D6-0F42-EB56-59C695353BC0"),
                    Name = "其他租户",
                    Host = "localhost:5000",
                    CreatedTime = DateTime.Parse("2020-10-06 20:20:32"),
                    Schema = "furion" // Schema
                }
            };
        }
    }
}
```

:::note 特别说明

该步骤只在 `Code First` 方式执行，`Database First` 无需配置种子数据。

:::

### 11.7.4 根据模型创建 `Tenant` 表

```shell
Add-Migration add_tenant_table -Context MultiTenantDbContext
```

```shell
Update-Database -Context MultiTenantDbContext
```

### 11.7.5 实现 `IMultiTenantOnSchema` 接口

在需要多租户的数据库上下文中实现 `IMultiTenantOnSchema` 接口，如：

```cs {6,12-15}
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
    [AppDbContext("Sqlite3ConnectionString")]
    public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnSchema
    {
        public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
        {
        }

        public string GetSchemaName()
        {
            return base.Tenant?.Schema??"dbo";
        }
    }
}
```

:::important 特别说明

`base.Tenant` 只是 `Furion` 框架提供的默认租户实现方法，如果不能满足业务需求，只需要在 `GetSchemaName` 里面写你的业务代码即可，也就是无需调用 `base.Tenant`。如：

```cs
public string GetSchemaName()
{
   // 这里是你获取 Schema 的逻辑
   return 你的 Schema;
}
```

:::

### 11.7.6 关于 `Code First 数据迁移`

基于 `Schema` 方式比较特别，生成数据迁移的时候没办法获取租户信息，所以建议**分开多次迁移**，如：

```cs
public string GetSchemaName()
{
    return base.Tenant?.Schema?? "租户一Schema";
}
```

```cs
public string GetSchemaName()
{
    return base.Tenant?.Schema?? "租户二Schema";
}
```

这样就可以在迁移的时候生成多次迁移了。

## 11.8 自定义 `Tenant` 类型

默认情况下，`Furion` 框架提供了内置的 `Tenant` 类型，方便大家快速实现 `SaaS` 多租户功能，如果需要自定义多租户 `Tenant` 类型，只需要启用以下配置即可：

### 11.8.1 启动自定义多租户类型配置

```cs {3}
services.AddDatabaseAccessor(options =>
{
    options.CustomizeMultiTenants();    // 启用自定义多租户类型，有一个默认参数，配置多租户表字段名
    options.AddDbPool<FurionDbContext>(DbProvider.Sqlite);
});
```

### 11.8.2 自定义租户类

```cs {6}
using System;
using System.ComponentModel.DataAnnotations.Schema;

namespace Furion.Core
{
    public class MyTenant : IEntity<MultiTenantDbContextLocator>
    {
        [Key]
        public Guid TenantId { get; set; }

        public string Name { get; set; }

        public string Host { get; set; }
    }
}
```

如果需要查询该租户信息，可通过以下代码获取，如：

```cs
var tenantDbContext = Db.GetDbContext<MultiTenantDbContextLocator>();
var myTenant = tenantDbContext.Set<MyTenant>();
```

## 11.9 刷新租户缓存

`Furion` 框架会在租户上下文第一次查询时候将租户表缓存起来，避免频发查询数据库，如果更新了租户表，则需要手动刷新租户信息，如：

```cs
using Furion.DatabaseAccessor.Extensions;

// 在更新租户信息后调用
_repository.Context.RefreshTenantCache();
```

## 11.10 反馈与建议

:::note 与我们交流

给 Furion 提 [Issue](https://gitee.com/dotnetchina/Furion/issues/new?issue)。
